package MusicLandscape.application;

import java.beans.XMLDecoder;
import java.beans.XMLEncoder;
import java.io.*;
import java.util.*;
import java.util.function.Consumer;
import java.util.regex.Pattern;

import MusicLandscape.container.MyTrackContainer;
import MusicLandscape.entities.Track;
import MusicLandscape.util.ConsoleScanable;
import MusicLandscape.util.ConsoleScanner;
import MusicLandscape.util.MyFormatter;
import MusicLandscape.util.MyMatcher;
import MusicLandscape.util.comparators.DurationComparator;
import MusicLandscape.util.comparators.PerformerComparator;
import MusicLandscape.util.comparators.TitleComparator;
import MusicLandscape.util.comparators.WriterComparator;
import MusicLandscape.util.comparators.YearComparator;
import MusicLandscape.util.formatters.CSVTrackFormatter;
import MusicLandscape.util.formatters.LongTrackFormatter;
import MusicLandscape.util.formatters.ShortTrackFormatter;
import MusicLandscape.util.io.MyTrackCSVReader;
import MusicLandscape.util.io.MyWriter;
import MusicLandscape.util.matcher.*;
import org.testng.util.Strings;

/**
 * FinalTrackDataBase
 * ==================
 * <p>
 * This is "a text based menu-driven, object-oriented console application with a sortable and searchable list of tracks
 * and multiple display formats that supports saving/loading of data to/from a .csv file."
 *
 * <p>
 * It also supports loading from and saving to XML files.
 *
 * @author Jonas Altrock (ew20b126@technikum-wien.at)
 * @version 1
 * @since LabWork
 */
public class Main {
    private final Pattern confirmation = Pattern.compile("^(y|yes)$", Pattern.CASE_INSENSITIVE);
    private final MyTrackContainer db = new MyTrackContainer();
    private final List<Comparator<Track>> comparators = new LinkedList<Comparator<Track>>();
    private final List<MyFormatter<Track>> formatters = new LinkedList<MyFormatter<Track>>();
    private final List<MyMatcher<Track>> matchers = new LinkedList<MyMatcher<Track>>();

    private Comparator<Track> theComp;
    private boolean asc = true;
    private MyFormatter<Track> theFormat;
    private final Stack<Menu> menus = new Stack<>();

    {
        comparators.add(theComp = new TitleComparator());
        comparators.add(new DurationComparator());
        comparators.add(new WriterComparator());
        comparators.add(new PerformerComparator());
        comparators.add(new YearComparator());

        matchers.add(new AlwaysMatcher());
        matchers.add(new TitleMatcher(""));
        matchers.add(new DurationMatcher());
        matchers.add(new PerformerMatcher(""));
        matchers.add(new WriterMatcher(""));
        matchers.add(new YearMatcher());

        formatters.add(theFormat = new LongTrackFormatter());
        formatters.add(new ShortTrackFormatter());
        formatters.add(new CSVTrackFormatter());

        Menu mainMenu = new Menu("Main", "Select action", "show menu", new MenuItem[]{
                new CallbackMenuItem("display selection", Main.this::display),
                new CallbackMenuItem("add track", Main.this::addTrack),
                new CallbackMenuItem("edit track", Main.this::editTracks),
                new CallbackMenuItem("reset selection", Main.this::resetSelection),
                new CallbackMenuItem("filter selection", Main.this::filterSelection),
                new CallbackMenuItem("remove selection", Main.this::removeSelection),
                new CallbackMenuItem("reverse sorting order", Main.this::reverseSortOrder),
                new CallbackMenuItem("select sorting", Main.this::selectSorting),
                new CallbackMenuItem("select formatting", Main.this::selectFormatting),
                new CallbackMenuItem("load tracks from CSV", Main.this::loadTracks),
                new CallbackMenuItem("save selection to CSV", Main.this::saveTracks),
                new CallbackMenuItem("load tracks from XML", Main.this::loadXML),
                new CallbackMenuItem("save selection to XML", Main.this::saveXML),
        });

        menus.push(mainMenu);
    }

    private static final String WELCOME_TEXT = "Welcome to the FinalTrackDataBase";
    private static final String GOOD_BYE_TEXT = "Thank you for using FinalTrackDataBase";

    private static abstract class MenuItem {
        String text;

        abstract void execute();

        MenuItem(String s) {
            text = s;
        }

        public String toString() {
            return text;
        }
    }

    private static class CallbackMenuItem extends MenuItem {
        Runnable cb;

        CallbackMenuItem(String s, Runnable r) {
            super(s);
            cb = r;
        }

        @Override
        void execute() {
            cb.run();
        }
    }

    private static class SelectionMenuItem<T> extends MenuItem {
        T option;
        boolean selected;
        Consumer<T> onSelect;

        public SelectionMenuItem(T value, boolean chosen, Consumer<T> callback) {
            super(value.toString());
            option = value;
            selected = chosen;
            onSelect = callback;
        }

        @Override
        void execute() {
            onSelect.accept(option);
        }

        @Override
        public String toString() {
            return super.toString() + (selected ? " (selected)" : "");
        }
    }

    private static class Menu {
        private final ArrayList<MenuItem> menu = new ArrayList<>();
        private final String prompt;
        private final String title;


        public Menu(String title, String prompt, String displayMenuLabel) {
            this.title = title;
            this.prompt = prompt;
            menu.add(new MenuItem(displayMenuLabel) {
                void execute() {
                    display();
                }
            });
        }

        public Menu(String title, String prompt, String displayMenuLabel, MenuItem[] items) {
            this(title, prompt, displayMenuLabel);
            menu.addAll(Arrays.asList(items));
        }

        void display() {
            for (int i = 0; i < menu.size(); i++) {
                String item = menu.get(i).toString();

                if (item != null) {
                    System.out.println(i + ":\t" + item);
                }
            }

            System.out.println("99:\texit");
        }

        public boolean execute(int item) {
            if (item < 0 || item >= menu.size()) {
                return false;
            }

            menu.get(item).execute();
            return true;
        }

        public String getTitle() {
            return title;
        }

        public String getPrompt() {
            return prompt;
        }
    }

    /**
     * Enter into a submenu/selection.
     *
     * @param m the Menu
     */
    public void enter(Menu m) {
        menus.push(m);
        m.execute(0);
    }

    /**
     * Exit from a submenu/selection.
     *
     * @return the Menu we just left
     */
    public Menu exit() {
        if (menus.size() > 1) {
            return menus.pop();
        }
        return null;
    }

    /**
     * Main application entry point from the command line.
     *
     * @param args arguments (ignored here)
     */
    public static void main(String[] args) {
        System.out.println(WELCOME_TEXT);
        new Main().go();
        System.out.println(GOOD_BYE_TEXT);
    }

    /**
     * Get a prompt that shows to the user the menu hierarchy and the currently asked item.
     *
     * @return a prompt string
     */
    public String prompt() {
        String path = Strings.join(" > ", menus.stream().map(Menu::getTitle).toArray(String[]::new));
        return (path.isEmpty() ? menus.peek().getTitle() : path) + " > " + menus.peek().getPrompt();
    }

    /**
     * Execute the menu input loop.
     */
    public void go() {
        // initial action: display menu
        menus.peek().execute(0);

        while (true) {
            String input = ConsoleScanner.nonEmptyString.scan(prompt());

            if (input == null) {
                continue;
            }

            try {
                int action = Integer.parseInt(input);

                if (menus.peek().execute(action)) {
                    continue;
                }
            } catch (NumberFormatException ignored) {
            }

            if (exit() != null) {
                continue;
            }

            String confirm = ConsoleScanner.nonEmptyString.scan("Exit? (y/N)");

            if (confirm != null) {
                if (confirmation.matcher(confirm).matches()) {
                    break;
                }
            }

            // if no action was matched and user does not want to exit, display menu again
            menus.peek().execute(0);
        }
    }

    /**
     * Display the track database selection
     */
    public void display() {
        System.out.println("Displaying selection:");

        if (db.size() == 0) {
            System.out.print("No tracks stored.\n");
            return;
        }

        if (db.selection().length == 0) {
            System.out.print("Selection empty.\n");
            return;
        }

        System.out.println('\n' + theFormat.header());
        System.out.println(theFormat.topSeparator());
        for (Track tt : db.selection())
            System.out.println(theFormat.format(tt));
        System.out.println();

        System.out.printf("%d out of %d tracks selected.\n", db.selection().length, db.size());
    }

    /**
     * Add a track to the database.
     */
    public void addTrack() {
        System.out.println("Adding new track.");
        Track t = new Track();

        if (t.scan()) {
            System.out.println("Added track " + t);
            Main.this.db.add(t);
        } else {
            System.out.println("You did not enter any data. Track discarded.");
        }
    }

    /**
     * Edit a specific track via the ConsoleScanable process.
     *
     * @param t the track to modify
     */
    public void editTrack(Track t) {
        if (t.scan()) {
            System.out.println("Successfully changed " + t);
        } else {
            System.out.println("Track remains unchanged.");
        }
    }

    /**
     * Edit tracks: select which track to edit, then modify that.
     */
    public void editTracks() {
        Consumer<Track> selectTrack = (t) -> {
            editTrack(t);
            db.sort(theComp, asc);
            exit();
        };

        MenuItem[] tracks = Arrays.stream(db.selection()).map((t) -> new SelectionMenuItem<>(t, false, selectTrack)).toArray(MenuItem[]::new);
        enter(new Menu("Edit", "Select track", null, tracks));
    }

    /**
     * Reset the track container selection.
     */
    public void resetSelection() {
        db.reset();
        db.sort(theComp, asc);
        System.out.println("Resetting selection. " + db.selection().length + " tracks selected.");
    }

    /**
     * Choose the display formatting for tracks.
     */
    public void selectFormatting() {
        Consumer<MyFormatter<Track>> selectFormatter = (f) -> {
            System.out.println(f + " selected.");
            theFormat = f;
            exit();
        };
        MenuItem[] options = formatters.stream().map((f) -> new SelectionMenuItem<>(f, f == theFormat, selectFormatter)).toArray(MenuItem[]::new);
        enter(new Menu("Formatting", "Select formatting", null, options));
    }

    /**
     * Helper function to print the current sorting.
     */
    protected void printSorting() {
        System.out.println("Selection is now sorted " + theComp + " (" + (asc ? "ascending" : "descending") + ").");
    }

    /**
     * Select the sorting comparator.
     */
    public void selectSorting() {
        Consumer<Comparator<Track>> select = (c) -> {
            theComp = c;
            db.sort(theComp, asc);
            printSorting();
            exit();
        };
        MenuItem[] options = comparators.stream().map((c) -> new SelectionMenuItem<>(c, c == theComp, select)).toArray(MenuItem[]::new);
        enter(new Menu("Sorting", "Select sorting", null, options));
    }

    /**
     * Reverse the selection ordering.
     */
    public void reverseSortOrder() {
        asc = !asc;
        db.sort(theComp, asc);
        printSorting();
    }

    /**
     * Remove the selected tracks from the database.
     */
    public void removeSelection() {
        System.out.println("Removing selection.");
        String confirm = ConsoleScanner.nonEmptyString.unskippable().scan("Do you really wish to remove " + db.selection().length + " tracks? (y/N)");

        if (confirmation.matcher(confirm).matches()) {
            int removed = db.remove();
            System.out.println("Removed " + removed + " track(s).");
        } else {
            System.out.println("Database remains unchanged.");
        }
    }

    /**
     * Filter the track selection.
     */
    public void filterSelection() {
        System.out.println("Filtering selection.");
        Consumer<MyMatcher<Track>> onSelectFilter = (MyMatcher<Track> m) -> {
            if (m instanceof ConsoleScanable) {
                System.out.println("You may now modify the filter \"" + m + "\".");
                ((ConsoleScanable) m).scan();
            }

            System.out.println("\"" + m + "\" filter applied " +
                    "(" + db.filter(m) + " tracks filtered, " + db.selection().length + " tracks selected).");
            exit();
        };
        MenuItem[] options = matchers.stream().map((c) -> new SelectionMenuItem<>(c, false, onSelectFilter)).toArray(MenuItem[]::new);
        enter(new Menu("Filter", "Select filtering", null, options));
    }

    protected String askForFileName() {
        String fileName = ConsoleScanner.nonEmptyString.scan("Enter a file name");

        if (fileName == null) {
            System.out.println("No file name given.");
        }

        return fileName;
    }

    /**
     * Load tracks from a CSV file.
     */
    public void loadTracks() {
        System.out.println("Loading tracks from CSV file.");
        String fileName = askForFileName();

        if (fileName == null) {
            return;
        }

        FileReader file;

        try {
            file = new FileReader(fileName);
        } catch (Exception e) {
            System.out.println("Error: " + e.getMessage());
            return;
        }

        int num = 0;
        MyTrackCSVReader reader = new MyTrackCSVReader(new BufferedReader(file));
        Track t = reader.get();

        while (t != null) {
            if (db.add(t)) {
                num += 1;
            }

            t = reader.get();
        }

        System.out.println(num + " tracks imported.");
    }

    /**
     * Write tracks to a CSV file.
     */
    public void saveTracks() {
        System.out.println("Saving selected tracks to CSV file.");
        String fileName = askForFileName();

        if (fileName == null) {
            return;
        }

        FileWriter file;

        try {
            file = new FileWriter(fileName);
        } catch (Exception e) {
            System.out.println("Error: " + e.getMessage());
            return;
        }

        MyWriter<Track> writer = new MyWriter<>(file, new CSVTrackFormatter());
        int num = 0;

        for (Track t : db.selection()) {
            if (writer.put(t)) {
                num++;
            }
        }

        try {
            writer.close();
            System.out.println("Successfully wrote " + num + " tracks to " + fileName);
        } catch (IOException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }

    /**
     * Write tracks to an XML file.
     */
    public void saveXML() {
        System.out.println("Saving selected tracks to XML file.");
        String fileName = askForFileName();

        if (fileName == null) {
            return;
        }

        XMLEncoder encoder;
        FileOutputStream file;

        try {
            file = new FileOutputStream(fileName);
            encoder = new XMLEncoder(file);

            for (Track t : db.selection()) {
                encoder.writeObject(t);
            }

            encoder.flush();
            encoder.close();

            System.out.println("Successfully wrote " + db.selection().length + " tracks to " + fileName);
        } catch (Exception e) {
            System.out.println("Error: " + e.getMessage());
        }
    }

    /**
     * Load tracks from an XML file.
     */
    public void loadXML() {
        System.out.println("Loading tracks from XML file.");
        String fileName = askForFileName();

        if (fileName == null) {
            return;
        }

        XMLDecoder decoder;
        FileInputStream file;

        try {
            file = new FileInputStream(fileName);
            decoder = new XMLDecoder(file);
        } catch (Exception e) {
            System.out.println("Error: " + e.getMessage());
            return;
        }

        Track t;
        int read = 0;

        try {
            while (true) {
                t = (Track) decoder.readObject();

                if (t == null) {
                    break;
                }

                if (db.add(t)) {
                    read += 1;
                }
            }

            decoder.close();
        } catch (IndexOutOfBoundsException ignored) {
        } catch (Exception e) {
            System.out.println("Error: " + e.getMessage());
        }

        System.out.println("Successfully read " + read + " tracks from " + fileName);
    }
}
